diff --git a/endpoint_handlers/licenseinfo.js b/endpoint_handlers/licenseinfo.js
new file mode 100644
index 0000000..8c34be2
--- /dev/null
+++ b/endpoint_handlers/licenseinfo.js
@@ -0,0 +1,26 @@
+const LICENCEINFO_ENDPOINT = 'https://fp.trafikverket.se/Boka/licence-information'
+
+async function getLicenceInfo(req, res) {
+ // Check for the required parameters
+ let response = await fetch(LICENCEINFO_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36'
+ },
+ // body: JSON.stringify({
+ // licenceId: licenceId,
+ // }),
+ })
+ if (!response.ok) {
+ res.status(500).json({ message: 'Something went wrong' });
+ return;
+ }
+
+ let json = await response.json()
+ // console.log(json)
+
+ res.json(json)
+}
+
+module.exports = getLicenceInfo;
\ No newline at end of file
diff --git a/endpoint_handlers/metadata.js b/endpoint_handlers/metadata.js
new file mode 100644
index 0000000..ceffebe
--- /dev/null
+++ b/endpoint_handlers/metadata.js
@@ -0,0 +1,52 @@
+const METADATA_ENDPOINT = 'https://fp.trafikverket.se/boka/search-information'
+
+const METADATA_TEMPLATE = {
+ "bookingSession": {
+ "socialSecurityNumber": "",
+ "licenceId": "",
+ "bookingModeId": 0,
+ "ignoreDebt": false,
+ "ignoreBookingHindrance": false,
+ "examinationTypeId": 0,
+ "excludeExaminationCategories": [],
+ "rescheduleTypeId": 0,
+ "paymentIsActive": false,
+ "paymentReference": null,
+ "paymentUrl": null,
+ "searchedMonths": 0
+ }
+}
+
+async function getMetadataEndpoint(req, res) {
+ // Check for the required parameters
+ if (!req.body || !req.body.licenceId || !req.body.ssn) {
+ res.status(400).json({ message: 'Missing required parameters' });
+ return;
+ }
+ let ssn = req.body.ssn;
+ let licenceId = req.body.licenceId;
+
+ let body = METADATA_TEMPLATE
+ body.bookingSession.socialSecurityNumber = ssn
+ body.bookingSession.licenceId = licenceId
+
+ let response = await fetch(METADATA_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36'
+ },
+ body: JSON.stringify(body),
+ })
+ if (!response.ok) {
+ res.status(500).json({ message: 'Something went wrong' });
+ return;
+ }
+
+ let json = await response.json()
+ // console.log(json)
+
+ res.json(json)
+}
+
+module.exports = getMetadataEndpoint;
\ No newline at end of file
diff --git a/endpoint_handlers/occasions.js b/endpoint_handlers/occasions.js
new file mode 100644
index 0000000..db46b2e
--- /dev/null
+++ b/endpoint_handlers/occasions.js
@@ -0,0 +1,77 @@
+const OCCASION_ENDPOINT = 'https://fp.trafikverket.se/boka/occasion-bundles'
+
+const OCCASION_TEMPLATE = {
+ "bookingSession": {
+ "socialSecurityNumber": "",
+ "licenceId": 4,
+ "bookingModeId": 0,
+ "ignoreDebt": false,
+ "ignoreBookingHindrance": false,
+ "examinationTypeId": 0,
+ "excludeExaminationCategories": [],
+ "rescheduleTypeId": 0,
+ "paymentIsActive": true,
+ "paymentReference": null,
+ "paymentUrl": null,
+ "searchedMonths": 0
+ },
+ "occasionBundleQuery": {
+ "startDate": "1970-01-01T00:00:00.000Z",
+ "searchedMonths": 0,
+ "locationId": 1000060,
+ "nearbyLocationIds": [],
+ "languageId": 13,
+ "vehicleTypeId": 1,
+ "tachographTypeId": 1,
+ "occasionChoiceId": 1,
+ "examinationTypeId": 50
+ }
+}
+// "nearbyLocationIds": [1000121, 1000302, 1000047, 1000009, 1000096, 1000122, 1000066, 1000139, 1000077, 1000138, 1000317, 1000123, 1000069, 1000334, 1000106, 1000103, 1000012, 1000078, 1000318, 1000038, 1000005, 1000087, 1000130, 1000144],
+
+async function getOccasionEndpoint(req, res) {
+ // Check for the required parameters
+ if (!req.body || !req.body.licenceId || !req.body.ssn || !req.body.locationId || !req.body.examType) {
+ res.status(400).json({ message: 'Missing required parameters' });
+ return;
+ }
+ let ssn = req.body.ssn;
+ let licenceId = req.body.licenceId;
+ let locationId = req.body.locationId;
+ let nearbyIds = req.body.nearbyIds;
+ let examType = req.body.examType;
+
+ if (!locationId.length > 0) {
+ res.status(400).json({ message: 'Missing required parameters' });
+ return;
+ }
+
+ let body = OCCASION_TEMPLATE
+ body.bookingSession.socialSecurityNumber = ssn
+ body.bookingSession.licenceId = licenceId
+ body.occasionBundleQuery.locationId = locationId
+ body.occasionBundleQuery.nearbyLocationIds = nearbyIds
+ body.occasionBundleQuery.examinationTypeId = examType
+
+ console.log(body)
+
+ let response = await fetch(OCCASION_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36'
+ },
+ body: JSON.stringify(body),
+ })
+ if (!response.ok) {
+ res.status(500).json({ message: 'Something went wrong' });
+ return;
+ }
+
+ let json = await response.json()
+ // console.log(json)
+
+ res.json(json)
+}
+
+module.exports = getOccasionEndpoint;
\ No newline at end of file
diff --git a/endpoint_handlers/suggested.js b/endpoint_handlers/suggested.js
new file mode 100644
index 0000000..8fd36ed
--- /dev/null
+++ b/endpoint_handlers/suggested.js
@@ -0,0 +1,34 @@
+const SUGGESTED_URL = 'https://fp.trafikverket.se/boka/get-suggested-reservations-by-licence-and-ssn'
+
+async function getSuggestedEndpoint(req, res) {
+ // Check for the required parameters
+ if (!req.body || !req.body.licenceId || !req.body.ssn) {
+ res.status(400).json({ message: 'Missing required parameters' });
+ return;
+ }
+ let ssn = req.body.ssn;
+ let licenceId = req.body.licenceId;
+
+ let response = await fetch(SUGGESTED_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36'
+ },
+ body: JSON.stringify({
+ ssn: ssn,
+ licenceId: licenceId,
+ }),
+ })
+ if (!response.ok) {
+ res.status(500).json({ message: 'Something went wrong' });
+ return;
+ }
+
+ let json = await response.json()
+ console.log(json)
+
+ res.json(json)
+}
+
+module.exports = getSuggestedEndpoint;
\ No newline at end of file
diff --git a/html/index.html b/html/index.html
index 3fcbdcc..940049e 100644
--- a/html/index.html
+++ b/html/index.html
@@ -5,59 +5,39 @@
+
-
+
TFSearch
+
-
-
-
+
Kort |
Pris |
Datum |
+ Tid |
Länk |
-
- 1 |
- 2 |
- 3 |
- 4 |
-
-
- 1 |
- 2 |
- 3 |
- 4 |
-
-
- 1 |
- 2 |
- 3 |
- 4 |
-
diff --git a/index.js b/index.js
index a20db78..83fd3a9 100644
--- a/index.js
+++ b/index.js
@@ -1,5 +1,10 @@
const express = require('express');
+const getSuggestedEndpoint = require('./endpoint_handlers/suggested.js');
+const getMetadataEndpoint = require('./endpoint_handlers/metadata.js');
+const getOccasionsEndpoint = require('./endpoint_handlers/occasions.js');
+const getLicenceInfo = require('./endpoint_handlers/licenseinfo.js');
+
// Read the port from the environment if possible
const port = process.env.PORT || 3000;
@@ -8,13 +13,25 @@ const server = express();
// Set up the static directory to server cssa and other assets
server.use('/static', express.static('static'));
+server.use(express.json());
+server.use(
+ express.urlencoded({
+ extended: true,
+ })
+);
+
// Root path serves index
server.get('/', (req, res) => {
res.sendFile(__dirname + '/html/index.html');
});
+// Set up API endpoints
+server.post('/suggested', getSuggestedEndpoint)
+server.post('/metadata', getMetadataEndpoint)
+server.post('/occasions', getOccasionsEndpoint)
+server.post('/licenseinfo', getLicenceInfo)
+
// Listen and provide feedback
server.listen(port, () => {
console.log(`Server running on port ${port}`);
-}
-);
\ No newline at end of file
+});
\ No newline at end of file
diff --git a/static/script.js b/static/script.js
index 6a43b29..ebe92a8 100644
--- a/static/script.js
+++ b/static/script.js
@@ -1 +1,318 @@
-console.log("Javascript is executing properly...");
\ No newline at end of file
+console.log("Javascript is executing properly...");
+
+const QueryParams = {
+ SSN: "",
+ LicenseId: "",
+ LocationIds: [],
+ examType: "", // Unused for now
+}
+
+const AppState = {
+ LocationsFetched: "",
+ SearchMessage: "",
+ SearchResults: [],
+ LocationIndex: {},
+}
+
+// Clear the state and the UI
+async function clearState() {
+ AppState.SearchResults = [];
+ // AppState.LocationIndex = {};
+ // AppState.SearchMessage = "";
+ // searchResults.innerHTML = "";
+ resultsTable.innerHTML = "";
+ // locationSelector.innerHTML = "";
+ // locationSelector.disabled = true;
+}
+
+async function readState() {
+ clearState();
+ console.log("Checking state...")
+ if (ssnInput.value.length == 12 && !isNaN(ssnInput.value)) {
+ console.log("SSN is valid...")
+ QueryParams.SSN = ssnInput.value;
+ // if (!AppState.LocationsFetched)
+ await populateLicenses();
+ QueryParams.LicenseId = licenseSelector.value;
+ await populateLocations(await queryMetadata(QueryParams.SSN, QueryParams.LicenseId));
+ licenseSelector.disabled = false;
+ locationSelector.disabled = false;
+ } else {
+ // SSN was changed, but is not valid
+ QueryParams.SSN = "";
+ licenseSelector.disabled = true;
+ locationSelector.disabled = true;
+ return
+ }
+
+ if (licenseSelector.value.length > 0 && licenseSelector.value != AppState.LocationsFetched) {
+ AppState.LocationsFetched = licenseSelector.value;
+ QueryParams.LicenseId = licenseSelector.value;
+
+ QueryParams.examType = (await querySuggested(QueryParams.SSN, QueryParams.LicenseId)).data[0].examinationTypeId;
+
+ // await populateLocations(await queryMetadata(QueryParams.SSN, QueryParams.LicenseId));
+ return
+ }
+
+ if (locationSelector.value.length > 0 && AppState.LocationsFetched) {
+ // console.log(locationSelector.value)
+ if (locationSelector.value == "all") {
+ QueryParams.LocationIds = Object.keys(AppState.LocationIndex)
+ } else {
+ QueryParams.LocationIds = [locationSelector.value];
+ }
+ // console.log(QueryParams)
+ }
+ return
+}
+
+function readyForSearch() {
+ console.log("State is ready for search...")
+ return QueryParams.SSN.length == 12 && QueryParams.LicenseId.length > 0 && QueryParams.LocationIds.length > 0;
+}
+
+const licenseSelector = document.getElementById("license-selector");
+const ssnInput = document.getElementById("ssn-input");
+const locationSelector = document.getElementById("location-selector");
+const searchButton = document.getElementById("searchButton");
+const resultsTable = document.getElementById("results-table");
+// const resultsContainer = document.getElementById("results-container");
+
+async function executeSearch() {
+ console.log("Executing search...")
+ await readState()
+ console.log(QueryParams)
+ if (!readyForSearch()) {
+ console.log("Not ready for search...")
+ return;
+ }
+ await querySeveralOccasions();
+ // console.log(AppState.SearchResults)
+ await displaySearchResults();
+}
+
+async function displaySearchResults() {
+ // console.log(AppState.SearchResults)
+ console.log("Displaying search results...")
+ AppState.SearchResults.sort((a, b) => {
+ if (a.date < b.date) return -1;
+ if (a.date > b.date) return 1;
+ return 0;
+ })
+ console.log(AppState.SearchResults)
+ resultsTable.innerHTML = " Kort | Ort | Pris | Datum | Tid | Länk |
"
+ for (let occ of AppState.SearchResults) {
+ let tr = document.createElement("tr");
+ tr.className = "table-row";
+ let td_license = document.createElement("td");
+ let td_location = document.createElement("td");
+ let td_price = document.createElement("td");
+ let td_date = document.createElement("td");
+ let td_time = document.createElement("td");
+ let td_link = document.createElement("td");
+
+ td_license.innerText = occ.name;
+ td_location.innerText = occ.location;
+ td_price.innerText = occ.cost;
+ td_date.innerText = occ.date;
+ td_time.innerText = occ.time;
+ td_link.innerText = "-"
+
+ tr.appendChild(td_license);
+ tr.appendChild(td_location);
+ tr.appendChild(td_price);
+ tr.appendChild(td_date);
+ tr.appendChild(td_time);
+ tr.appendChild(td_link);
+
+ resultsTable.appendChild(tr);
+ }
+}
+
+async function querySuggested(ssn, licenseId) {
+ let msg = await (await fetch('/suggested', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ licenceId: licenseId,
+ ssn: ssn,
+ }),
+ })).json()
+ return msg
+}
+
+// The metadata is specific to the license type
+async function queryMetadata(ssn, licenseId) {
+ console.log("Getting metadata...")
+ let msg = await (await fetch('/metadata', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ licenceId: licenseId,
+ ssn: ssn,
+ }),
+ })).json()
+ return msg
+}
+
+async function queryOccasions() {
+ console.log("Querying occasions...")
+ let occasions = []
+ // console.log(QueryParams)
+ for (const locationId of QueryParams.LocationIds) {
+ let msg = await (await fetch('/occasions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ licenceId: QueryParams.LicenseId,
+ ssn: QueryParams.SSN,
+ locationId: locationId,
+ examType: QueryParams.examType,
+ }),
+ })).json()
+ if (!msg || !msg.data || !msg.data.bundles) continue;
+ for (let occasion of msg.data.bundles) {
+ occ = occasion.occasions[0];
+
+ occasions.push({
+ name: occ.name,
+ location: occ.locationName,
+ cost: occasion.cost,
+ date: occ.date,
+ time: occ.time,
+ });
+ }
+
+ console.log(occasions)
+ // return msg
+ // break
+ }
+ AppState.locationResults = occasions;
+}
+
+async function querySeveralOccasions() {
+ console.log("Querying several occasions...")
+ i = 0
+ while (i < QueryParams.LocationIds.length) {
+ let current_array = QueryParams.LocationIds.slice(i, i + 4)
+ console.log(current_array)
+ let request = await (await fetch('/occasions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ licenceId: QueryParams.LicenseId,
+ ssn: QueryParams.SSN,
+ locationId: current_array[0], // The first
+ nearbyIds: current_array.slice(1), // The rest
+ examType: QueryParams.examType,
+ }),
+ })).json()
+ if (!request || !request.data || !request.data.bundles) continue;
+ let bundles = request.data.bundles
+ for (let occasion of bundles) {
+ let occ = occasion.occasions[0];
+
+ AppState.SearchResults.push({
+ name: occ.name,
+ location: occ.locationName,
+ cost: occasion.cost,
+ date: occ.date,
+ time: occ.time,
+ });
+ }
+
+ i += 4
+ }
+}
+
+async function queryLicenseinfo() {
+ console.log("Querying license info...")
+ let msg = await (await fetch('/licenseinfo', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })).json()
+ return msg
+ // console.log(msg)
+}
+
+// let selector = document.getElementById('license-selector')
+async function populateLicenses() {
+ console.log("Populating licenses...")
+ let licenseinfo = await queryLicenseinfo()
+
+ // slice(0, 2) to only show the first two license types (car and motorcycle)
+ for (const license_cat of licenseinfo.data.licenceCategories.slice(0, 2)) {
+ for (const license of license_cat.licences) {
+ const new_option = document.createElement('option')
+ new_option.value = license.id
+ new_option.innerText = `${license.name} ${license.description}`
+ licenseSelector.appendChild(new_option)
+ }
+ }
+}
+
+// Executed each time the license type is changed
+async function licenceChanged(field) {
+ QueryParams.LicenseId = field.value
+ readState()
+
+ // let metadata_response = await querySuggested(QueryParams.SSN, QueryParams.LicenseId)
+ // if (!metadata_response.data[0]) {
+ // QueryParams.SearchMessage = "This license type is not available for this person."
+ // return
+ // }
+ // QueryParams.examType = metadata_response.data[0].examinationTypeId
+
+ // console.log(QueryParams)
+ // let metadata = await queryMetadata(QueryParams.SSN, QueryParams.LicenseId)
+ // populateLocations(metadata)
+}
+
+async function populateLocations(response_json) {
+ if (!response_json.data) {
+ console.log("No locations found, likely throttled")
+ return;
+ }
+ for (const location of response_json.data.locations) {
+ // Populate the location index so that we can retrieve other data later
+ AppState.LocationIndex[location.location.id] = location
+
+ let name = location.location.name
+ let id = location.location.id
+
+ const new_option = document.createElement('option')
+ new_option.value = id
+ new_option.innerText = `${name}`
+ locationSelector.appendChild(new_option)
+ locationSelector.disabled = false
+ searchButton.disabled = false
+ // console.log(name, id)
+ // const new_option = document.createElement('option')
+ // new_option.value = location.id
+ // new_option.innerText = location.name
+ // locationSelector.appendChild(new_option)
+ }
+ // console.log(AppState.LocationIndex)
+}
+
+async function locationChanged(field) {
+ readState()
+}
+
+let ssn_input_element = document.getElementById('ssn-input')
+async function ssnChanged(field) {
+ readState()
+}
+
+readState()
\ No newline at end of file
diff --git a/static/style.css b/static/style.css
index 25c7ef5..360e992 100644
--- a/static/style.css
+++ b/static/style.css
@@ -29,9 +29,11 @@
}
#search-container {
+ max-width: 800px;
background-color: #fff;
display: flex;
flex-direction: row;
+ width: 100%;
}
#search-container input {
@@ -55,6 +57,12 @@
font-size: 16px;
}
+#search-container select {
+ width: 100%;
+ padding: 0 20px;
+ font-size: 16px;
+}
+
#results-container {}
table {
@@ -66,7 +74,7 @@ th,
td {
border-bottom: 1px solid #ddd;
text-align: center;
- padding: 16px;
+ padding: 4px 16px;
}
tr:nth-child(even) {