Plugin Directory

Changeset 3763663


Ignore:
Timestamp:
10/24/2026 06:45:09 PM (2 hours ago)
Author:
leanmerce
Message:

Release Leanmerce Influencer Collaboration 1.0.8

Verify migration postconditions and preserve the existing collaboration, content and agreement workflows. Publish the exact approved package with its documented customer protection fixes to trunk and tags/1.0.8.

Location:
leanmerce-influencer-collaboration
Files:
10 added
2 deleted
26 edited
1 copied

Legend:

Unmodified
Added
Removed
  • leanmerce-influencer-collaboration/tags/1.0.8/leanmerce-influencer-collaboration.php

    r3722211 r3763663  
    33 * Plugin Name: Leanmerce Influencer Collaboration
    44 * Description: Manage influencer collaborations directly in WooCommerce, from onboarding and opportunities through fulfillment, content review, and history.
    5  * Version: 1.0.7
     5 * Version: 1.0.8
    66 * Requires at least: 6.9
    77 * Requires PHP: 8.1
    … …  
    2222}
    2323
    24 define( 'LEANMERCE_INFLUENCER_VERSION', '1.0.7' );
     24define( 'LEANMERCE_INFLUENCER_VERSION', '1.0.8' );
    2525define( 'LEANMERCE_INFLUENCER_DB_VERSION', '10.0.0' );
    2626define( 'LEANMERCE_INFLUENCER_FILE', __FILE__ );
  • leanmerce-influencer-collaboration/tags/1.0.8/readme.txt

    r3722211 r3763663  
    55Tested up to: 7.1
    66Requires PHP: 8.1
    7 Stable tag: 1.0.7
     7Stable tag: 1.0.8
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    4343Free/Core stores influencer profiles, manually entered social references, analytics evidence and historical metric records, private evidence and content files, applications, immutable commercial terms, factual external-agreement confirmations, fulfillment data, content review history, CRM tags, agreed-rights facts, and audit events on the WordPress site. It does not store social-provider credentials or access tokens. Private evidence and content use local encrypted storage. Linked WooCommerce orders follow WooCommerce and site retention settings.
    4444
     45== Upgrade Notice ==
     46
     47= 1.0.8 =
     48Back up the database, private uploads and WordPress salts. Records and completed agreements stay intact. Failed schema checks pause affected features; retry after five minutes once permissions recover. Large encrypted uploads may need smaller files when memory is limited.
     49
    4550== Changelog ==
     51
     52= 1.0.8 =
     53* Keep Free/Core reporting handlers and suspended-profile restrictions independent of Pro version and plugin load order.
     54* Keep localized validation messages readable in REST JSON and escape them once for HTML display.
     55* Verify required database tables, columns, and indexes before accepting setup as complete; retry failed setup with a bounded delay and pause affected plugin features safely.
     56* Count active and consumed allocations consistently when selecting creators, accepting offers, and confirming new external agreements.
     57* Reject encrypted content uploads that exceed the current server memory budget before loading file buffers, with a clear client error; preserve the existing encryption format and keys.
     58* Preserve existing records, completed agreements, upload encryption, and all current minimum requirements.
    4659
    4760= 1.0.7 =
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Admin/AgreementsPage.php

    r3722211 r3763663  
    55// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned custom tables require direct transactional queries; prepared-SQL security checks remain enabled.
    66use DomainException;
     7use Leanmerce\Influencer\Support\EscapedDomainException;
    78use Leanmerce\Influencer\Agreement\ExternalAgreementService;
    89use Leanmerce\Influencer\Database\Schema;
    … …  
    2223        /** @param array<string,mixed> $row */
    2324        private function state_action(array$row):void{$status=(string)$row['status'];$id=(int)$row['id'];if('contracted'===$status){echo'<a class="button button-primary" href="'.esc_url(admin_url('admin.php?page=leanmerce-fulfillment&collaboration_id='.$id)).'">'.esc_html__('Continue to fulfillment','leanmerce-influencer-collaboration').'</a>';}elseif('awaiting_influencer_acceptance'===$status){echo'<p class="lmi-callout">'.esc_html__('Waiting for creator acceptance. No shop action is available yet.','leanmerce-influencer-collaboration').'</p>';}elseif('content_pending'===$status){echo'<a class="button button-primary" href="'.esc_url(admin_url('admin.php?page=leanmerce-content')).'">'.esc_html__('Review content','leanmerce-influencer-collaboration').'</a>';}elseif('content_complete'===$status){echo'<a class="button" href="'.esc_url(admin_url('admin.php?page=leanmerce-collaborations&collaboration_id='.$id)).'">'.esc_html__('View completed collaboration','leanmerce-influencer-collaboration').'</a>';}}
    24         public function handle():void{check_admin_referer(self::ACTION);if(!isset($_POST['confirmed'])||'1'!==sanitize_text_field((string)wp_unslash($_POST['confirmed']))){wp_die(esc_html__('Explicit confirmation is required.','leanmerce-influencer-collaboration'));}try{(new ExternalAgreementService())->confirm(absint(wp_unslash($_POST['collaboration_id']??0)),get_current_user_id(),sanitize_text_field(wp_unslash($_POST['reference']??'')));}catch(DomainException$e){wp_die(esc_html($e->getMessage()));}wp_safe_redirect(admin_url('admin.php?page=leanmerce-agreements'));exit;}
     25        public function handle():void{check_admin_referer(self::ACTION);if(!isset($_POST['confirmed'])||'1'!==sanitize_text_field((string)wp_unslash($_POST['confirmed']))){wp_die(esc_html__('Explicit confirmation is required.','leanmerce-influencer-collaboration'));}try{(new ExternalAgreementService())->confirm(absint(wp_unslash($_POST['collaboration_id']??0)),get_current_user_id(),sanitize_text_field(wp_unslash($_POST['reference']??'')));}catch(DomainException$e){wp_die(esc_html($e instanceof EscapedDomainException?$e->plain_text():$e->getMessage()));}wp_safe_redirect(admin_url('admin.php?page=leanmerce-agreements'));exit;}
    2526}
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Agreement/ExternalAgreementService.php

    r3722211 r3763663  
    2727                $json=(string)wp_json_encode($this->sort($snapshot),JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);$hash=hash('sha256',$json);$now=current_time('mysql',true);
    2828                $wpdb->query('START TRANSACTION');
     29                $wpdb->get_var($wpdb->prepare('SELECT id FROM %i WHERE id=%d FOR UPDATE', $t['opportunities'], (int)$c['opportunity_id']));
     30                (new \Leanmerce\Influencer\Selection\SelectionService())->assert_agreement_capacity((int)$c['opportunity_id'], (int)$c['selection_offer_id']);
    2931                if(false===$wpdb->insert($t['final_terms'],array('collaboration_id'=>$collaboration_id,'schema_version'=>'1.0.0','snapshot_json'=>$json,'snapshot_hash'=>$hash,'source_selection_offer_id'=>(int)$offer['id'],'source_commercial_snapshot_hash'=>(string)$offer['commercial_snapshot_hash'],'merchant_legal_snapshot_id'=>0,'influencer_legal_snapshot_id'=>0,'contract_pack_key'=>'external_manual','contract_pack_version'=>'1.0.0','template_id'=>0,'created_gmt'=>$now))){$wpdb->query('ROLLBACK');throw new DomainException('Immutable Final Terms could not be created.');}
    3032                $id=(int)$wpdb->insert_id;$changed=$wpdb->update($t['collaborations'],array('status'=>'contracted','updated_gmt'=>$now),array('id'=>$collaboration_id,'status'=>$c['status']));if(1!==$changed){$wpdb->query('ROLLBACK');throw new DomainException('Collaboration changed before agreement confirmation.');}$wpdb->query('COMMIT');
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Bootstrap.php

    r3722211 r3763663  
    3737use Leanmerce\Influencer\Rest\FulfillmentController;
    3838use Leanmerce\Influencer\Rest\ContentController;
    39 use Leanmerce\Influencer\Rest\ReportingController;
     39use Leanmerce\Influencer\Core\Rest\ReportingController;
    4040use Leanmerce\Influencer\Roles\Capabilities;
    4141
    … …  
    6767                }
    6868
    69                 ( new MigrationManager() )->maybe_migrate();
     69                if ( ! ( new MigrationManager() )->maybe_migrate() ) {
     70                        add_action( 'admin_notices', static function (): void {
     71                                if ( current_user_can( 'manage_options' ) ) {
     72                                        echo '<div class="notice notice-error"><p>' . esc_html__( 'Influencer Collaboration is temporarily paused because its database setup could not be verified. Check database permissions and reload after five minutes. Existing records and other WooCommerce features are preserved.', 'leanmerce-influencer-collaboration' ) . '</p></div>';
     73                                }
     74                        } );
     75                        return;
     76                }
    7077                Capabilities::maybe_sync();
    7178
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Content/ContentFileService.php

    r3722211 r3763663  
    88
    99use DomainException;
     10use Leanmerce\Influencer\Support\EscapedDomainException;
    1011use Leanmerce\Influencer\Database\Schema;
    1112use Leanmerce\Influencer\Storage\EncryptedLocalStorage;
    … …  
    1718        public function max_bytes():int{return(int)apply_filters('leanmerce_influencer_content_max_upload_bytes',min(104857600,wp_max_upload_size()));}
    1819        /** @param array{name:string,tmp_name:string,size:int,error:int} $file */
    19         public function upload(int$profile_id,int$user_id,array$file):int{if(UPLOAD_ERR_OK!==(int)$file['error']||(int)$file['size']<1||(int)$file['size']>$this->max_bytes()){throw new DomainException('The content file is empty, too large, or could not be uploaded.');}$allowed=array('jpg|jpeg|jpe'=>'image/jpeg','png'=>'image/png','webp'=>'image/webp','mp4'=>'video/mp4','mov|qt'=>'video/quicktime');$checked=wp_check_filetype_and_ext($file['tmp_name'],$file['name'],$allowed);$mime=(string)($checked['type']??'');$ext=strtolower((string)($checked['ext']??''));$map=array('image/jpeg'=>array('jpg','jpeg','jpe'),'image/png'=>array('png'),'image/webp'=>array('webp'),'video/mp4'=>array('mp4'),'video/quicktime'=>array('mov','qt'));if(!isset($map[$mime])||!in_array($ext,$map[$mime],true)){throw new DomainException('Only inspected JPEG, PNG, WEBP, MP4, or MOV content is accepted.');}$stored=$this->storage->store($file['tmp_name']);global$wpdb;$table=Schema::table_names()['files'];if(false===$wpdb->insert($table,array('influencer_profile_id'=>$profile_id,'storage_key'=>$stored['storage_key'],'original_name'=>sanitize_file_name(wp_basename($file['name'])),'mime_type'=>$mime,'size_bytes'=>$stored['size'],'content_sha256'=>$stored['sha256'],'created_by'=>$user_id,'created_gmt'=>current_time('mysql',true)))){$this->storage->delete($stored['storage_key']);throw new DomainException('Private content metadata could not be saved.');}return(int)$wpdb->insert_id;}
     20        public function upload(int$profile_id,int$user_id,array$file):int{if(UPLOAD_ERR_OK!==(int)$file['error']||(int)$file['size']<1||(int)$file['size']>$this->max_bytes()){throw new DomainException('The content file is empty, too large, or could not be uploaded.');}if($this->storage instanceof EncryptedLocalStorage&&(int)$file['size']>$this->storage->max_storable_bytes()){throw new EscapedDomainException(esc_textarea(__('The content file exceeds the safe memory budget for this server. Choose a smaller file.','leanmerce-influencer-collaboration')));}$allowed=array('jpg|jpeg|jpe'=>'image/jpeg','png'=>'image/png','webp'=>'image/webp','mp4'=>'video/mp4','mov|qt'=>'video/quicktime');$checked=wp_check_filetype_and_ext($file['tmp_name'],$file['name'],$allowed);$mime=(string)($checked['type']??'');$ext=strtolower((string)($checked['ext']??''));$map=array('image/jpeg'=>array('jpg','jpeg','jpe'),'image/png'=>array('png'),'image/webp'=>array('webp'),'video/mp4'=>array('mp4'),'video/quicktime'=>array('mov','qt'));if(!isset($map[$mime])||!in_array($ext,$map[$mime],true)){throw new DomainException('Only inspected JPEG, PNG, WEBP, MP4, or MOV content is accepted.');}$stored=$this->storage->store($file['tmp_name']);global$wpdb;$table=Schema::table_names()['files'];if(false===$wpdb->insert($table,array('influencer_profile_id'=>$profile_id,'storage_key'=>$stored['storage_key'],'original_name'=>sanitize_file_name(wp_basename($file['name'])),'mime_type'=>$mime,'size_bytes'=>$stored['size'],'content_sha256'=>$stored['sha256'],'created_by'=>$user_id,'created_gmt'=>current_time('mysql',true)))){$this->storage->delete($stored['storage_key']);throw new DomainException('Private content metadata could not be saved.');}return(int)$wpdb->insert_id;}
    2021        /** @return array{metadata:array<string,mixed>,content:string} */
    2122        public function read_authorized(int$file_id,int$user_id):array{global$wpdb;$t=Schema::table_names();$row=$wpdb->get_row($wpdb->prepare("SELECT f.*,p.user_id owner_user_id FROM %i f INNER JOIN %i p ON p.id=f.influencer_profile_id INNER JOIN %i sf ON sf.private_file_id=f.id WHERE f.id=%d AND f.deleted_gmt IS NULL LIMIT 1", $t['files'], $t['profiles'], $t['submission_files'],$file_id),ARRAY_A);if(!is_array($row)||((int)$row['owner_user_id']!==$user_id&&!user_can($user_id,'view_leanmerce_influencer_content'))){throw new DomainException('Private content not found.');}return array('metadata'=>$row,'content'=>$this->storage->read((string)$row['storage_key']));}
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Database/MigrationManager.php

    r3722211 r3763663  
    55namespace Leanmerce\Influencer\Database;
    66
     7// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Current plugin schema, migration locks, and allocation checks require live prepared queries.
     8
    79final class MigrationManager {
    810        private const OPTION_KEY = 'leanmerce_influencer_collaboration_db_version';
     11        private const HEALTH_KEY = 'leanmerce_influencer_collaboration_schema_health';
     12        private const RETRY_KEY = 'leanmerce_influencer_collaboration_schema_retry_after';
     13        private const RETRY_SECONDS = 300;
     14        private const HEALTH_SECONDS = 86400;
    915
    10         public function maybe_migrate(): void {
     16        public function maybe_migrate(): bool {
    1117                $current = (string) get_option( self::OPTION_KEY, '' );
    12                 if ( version_compare( $current, LEANMERCE_INFLUENCER_DB_VERSION, '<' ) ) {
    13                         $this->migrate();
     18                if ( version_compare( $current, LEANMERCE_INFLUENCER_DB_VERSION, '>' ) ) {
     19                        return false;
     20                }
     21                $health = get_option( self::HEALTH_KEY, array() );
     22                if ( LEANMERCE_INFLUENCER_DB_VERSION === $current && is_array( $health ) && ( $health['version'] ?? '' ) === LEANMERCE_INFLUENCER_DB_VERSION && (int) ( $health['until'] ?? 0 ) > time() ) {
     23                        return true;
     24                }
     25                if ( (int) get_option( self::RETRY_KEY, 0 ) > time() ) {
     26                        return false;
     27                }
     28                return $this->migrate();
     29        }
     30
     31        public function migrate(): bool {
     32                global $wpdb;
     33                if ( version_compare( self::current_version(), LEANMERCE_INFLUENCER_DB_VERSION, '>' ) ) {
     34                        return false;
     35                }
     36                $lock = 'lmi-schema-' . substr( hash( 'sha256', DB_NAME . '|' . $wpdb->prefix ), 0, 40 );
     37                if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) ) {
     38                        return false;
     39                }
     40                try {
     41                        delete_option( self::HEALTH_KEY );
     42                        $healthy = SchemaHealth::is_current();
     43                        if ( ! $healthy ) {
     44                                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     45                                foreach ( Schema::create_statements() as $statement ) {
     46                                        dbDelta( $statement );
     47                                }
     48                        }
     49                        if ( ! $healthy && ! SchemaHealth::is_current() ) {
     50                                return $this->failed();
     51                        }
     52                        update_option( self::OPTION_KEY, LEANMERCE_INFLUENCER_DB_VERSION, false );
     53                        if ( self::current_version() !== LEANMERCE_INFLUENCER_DB_VERSION ) {
     54                                return $this->failed();
     55                        }
     56                        $health = array( 'version' => LEANMERCE_INFLUENCER_DB_VERSION, 'until' => time() + self::HEALTH_SECONDS );
     57                        update_option( self::HEALTH_KEY, $health, false );
     58                        if ( get_option( self::HEALTH_KEY, array() ) !== $health ) {
     59                                return $this->failed();
     60                        }
     61                        delete_option( self::RETRY_KEY );
     62                        do_action( 'leanmerce_influencer_core_migrated', LEANMERCE_INFLUENCER_DB_VERSION );
     63                        return true;
     64                } catch ( \Throwable $exception ) {
     65                        return $this->failed();
     66                } finally {
     67                        $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
    1468                }
    1569        }
    1670
    17         public function migrate(): void {
    18                 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    19 
    20                 foreach ( Schema::create_statements() as $statement ) {
    21                         dbDelta( $statement );
    22                 }
    23 
    24                 update_option( self::OPTION_KEY, LEANMERCE_INFLUENCER_DB_VERSION, false );
    25                 do_action( 'leanmerce_influencer_core_migrated', LEANMERCE_INFLUENCER_DB_VERSION );
     71        private function failed(): bool {
     72                delete_option( self::HEALTH_KEY );
     73                update_option( self::RETRY_KEY, time() + self::RETRY_SECONDS, false );
     74                return false;
    2675        }
    2776
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Infrastructure/Activator.php

    r3722211 r3763663  
    1717                }
    1818
    19                 ( new MigrationManager() )->migrate();
     19                if ( ! ( new MigrationManager() )->maybe_migrate() ) {
     20                        return;
     21                }
    2022                Capabilities::install();
    2123
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Rest/AgreementController.php

    r3722211 r3763663  
    66
    77use DomainException;
     8use Leanmerce\Influencer\Support\EscapedDomainException;
    89use Leanmerce\Influencer\Agreement\ExternalAgreementService;
    910use WP_Error;
    … …  
    1415        public function register():void{add_action('rest_api_init',array($this,'routes'));}
    1516        public function routes():void{register_rest_route('leanmerce-influencer-collaboration/v1','/agreements/(?P<id>\d+)/external',array('methods'=>'POST','callback'=>array($this,'confirm'),'permission_callback'=>static fn():bool=>current_user_can('manage_leanmerce_influencer_contracts')));}
    16         public function confirm(WP_REST_Request$r):WP_REST_Response|WP_Error{try{return new WP_REST_Response((new ExternalAgreementService())->confirm((int)$r['id'],get_current_user_id(),sanitize_text_field((string)$r->get_param('reference'))),201);}catch(DomainException$e){return new WP_Error('invalid_external_agreement',$e->getMessage(),array('status'=>400));}}
     17        public function confirm(WP_REST_Request$r):WP_REST_Response|WP_Error{try{return new WP_REST_Response((new ExternalAgreementService())->confirm((int)$r['id'],get_current_user_id(),sanitize_text_field((string)$r->get_param('reference'))),201);}catch(DomainException$e){return new WP_Error('invalid_external_agreement',$e instanceof EscapedDomainException?$e->plain_text():$e->getMessage(),array('status'=>400));}}
    1718}
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Rest/ContentController.php

    r3722211 r3763663  
    66
    77use DomainException;
     8use Leanmerce\Influencer\Support\EscapedDomainException;
    89use Leanmerce\Influencer\Content\ContentFileService;
    910use Leanmerce\Influencer\Content\ContentRepository;
    … …  
    4445        public function start(WP_REST_Request$r):WP_REST_Response|WP_Error{return$this->run(fn()=>(new ContentWorkflowService())->start((int)$r['id'],get_current_user_id(),true),201);}
    4546        public function review(WP_REST_Request$r):WP_REST_Response|WP_Error{$input=InputSanitizer::json($r,array('action','feedback','version'));return$this->run(fn()=>(new ContentWorkflowService())->review((int)$r['id'],get_current_user_id(),sanitize_key((string)($input['action']??'')),(string)($input['feedback']??''),absint($input['version']??0)));}
    46         private function run(callable$fn,int$status=200):WP_REST_Response|WP_Error{try{return new WP_REST_Response($fn(),$status);}catch(DomainException$e){$conflict=(bool)preg_match('/changed|already|newer|state|currently|limit/i',$e->getMessage());return new WP_Error('invalid_content_operation',$e->getMessage(),array('status'=>$conflict?409:400));}}
     47        private function run(callable$fn,int$status=200):WP_REST_Response|WP_Error{try{return new WP_REST_Response($fn(),$status);}catch(DomainException$e){$message=$e instanceof EscapedDomainException?$e->plain_text():$e->getMessage();$conflict=(bool)preg_match('/changed|already|newer|state|currently|limit/i',$message);return new WP_Error('invalid_content_operation',$message,array('status'=>$conflict?409:400));}}
    4748}
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Selection/SelectionService.php

    r3722211 r3763663  
    88
    99use DomainException;
     10use Leanmerce\Influencer\Support\EscapedDomainException;
    1011use Leanmerce\Influencer\Application\ApplicationRepository;
    1112use Leanmerce\Influencer\Application\ApplicationService;
    … …  
    5455        private function respond( array $offer, string $status, int $user_id ): void { global $wpdb;$t=Schema::table_names();$wpdb->query('START TRANSACTION');$changed=$wpdb->query($wpdb->prepare("UPDATE %i SET status=%s,pending_guard=NULL,responded_gmt=%s WHERE id=%d AND status='pending' AND pending_guard=1", $t['selection_offers'],$status,current_time('mysql',true),(int)$offer['id']));if(1!==$changed){$wpdb->query('ROLLBACK');throw new DomainException('This selection offer changed before your response.');}$wpdb->update($t['soft_allocations'],array('status'=>'released','released_gmt'=>current_time('mysql',true)),array('selection_offer_id'=>(int)$offer['id'],'status'=>'active'));$wpdb->update($t['applications'],array('status'=>'submitted','updated_gmt'=>current_time('mysql',true)),array('id'=>(int)$offer['application_id']));$wpdb->query('COMMIT');(new AuditService())->record('application',(int)$offer['application_id'],'selection_offer.'.$status,array('context'=>(string)$offer['commercial_snapshot_hash']),$user_id);(new AuditService())->record('application',(int)$offer['application_id'],'soft_allocation.released',array('source'=>$status),$user_id); }
    5556        private function owned_offer( int $id, int $user_id ): array { $offer=$this->offer($id);if(!$offer){throw new DomainException('Selection offer not found.');}(new ApplicationService())->owned_application((int)$offer['application_id'],$user_id);return$offer; }
    56         private function assert_capacity( array $opportunity, ?int $exclude_offer ): void { if(empty($opportunity['max_collaborations'])){return;}global $wpdb;$t=Schema::table_names();if($exclude_offer){$count=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE opportunity_id=%d AND status='active' AND selection_offer_id<>%d",$t['soft_allocations'],(int)$opportunity['id'],$exclude_offer));}else{$count=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE opportunity_id=%d AND status='active'",$t['soft_allocations'],(int)$opportunity['id']));}if($count>=(int)$opportunity['max_collaborations']){throw new DomainException('This opportunity has reached its collaboration capacity.');} }
    57         private function assert_product_capacity( int $opportunity_product_id, int $quantity, ?int $exclude_offer ): void { global $wpdb;$t=Schema::table_names();$cap=$wpdb->get_var($wpdb->prepare("SELECT capacity FROM %i WHERE id=%d",$t['opportunity_products'],$opportunity_product_id));if(null===$cap){return;}if($exclude_offer){$used=(int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(quantity),0) FROM %i WHERE opportunity_product_id=%d AND status='active' AND selection_offer_id<>%d",$t['soft_allocations'],$opportunity_product_id,$exclude_offer));}else{$used=(int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(quantity),0) FROM %i WHERE opportunity_product_id=%d AND status='active'",$t['soft_allocations'],$opportunity_product_id));}if($used+$quantity>(int)$cap){throw new DomainException('This product allocation has reached capacity.');} }
     57        private function assert_capacity( array $opportunity, ?int $exclude_offer ): void { if(empty($opportunity['max_collaborations'])){return;}global $wpdb;$t=Schema::table_names();if($exclude_offer){$count=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE opportunity_id=%d AND status IN ('active','consumed') AND selection_offer_id<>%d",$t['soft_allocations'],(int)$opportunity['id'],$exclude_offer));}else{$count=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE opportunity_id=%d AND status IN ('active','consumed')",$t['soft_allocations'],(int)$opportunity['id']));}if($count>=(int)$opportunity['max_collaborations']){$wpdb->query('ROLLBACK');throw new DomainException('This opportunity has reached its collaboration capacity.');} }
     58        private function assert_product_capacity( int $opportunity_product_id, int $quantity, ?int $exclude_offer ): void { global $wpdb;$t=Schema::table_names();$cap=$wpdb->get_var($wpdb->prepare("SELECT capacity FROM %i WHERE id=%d",$t['opportunity_products'],$opportunity_product_id));if(null===$cap){return;}if($exclude_offer){$used=(int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(quantity),0) FROM %i WHERE opportunity_product_id=%d AND status IN ('active','consumed') AND selection_offer_id<>%d",$t['soft_allocations'],$opportunity_product_id,$exclude_offer));}else{$used=(int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(quantity),0) FROM %i WHERE opportunity_product_id=%d AND status IN ('active','consumed')",$t['soft_allocations'],$opportunity_product_id));}if($used+$quantity>(int)$cap){$wpdb->query('ROLLBACK');throw new DomainException('This product allocation has reached capacity.');} }
     59
     60        /** Validate a still-pending agreement against the same capacity policy, without rewriting prior terms. */
     61        public function assert_agreement_capacity( int $opportunity_id, int $offer_id ): void {
     62                global $wpdb;
     63                $t = Schema::table_names();
     64                $opportunity = ( new OpportunityRepository() )->find( $opportunity_id );
     65                $allocation = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE selection_offer_id=%d', $t['soft_allocations'], $offer_id ), ARRAY_A );
     66                if ( ! $opportunity || ! $allocation || 'active' !== $allocation['status'] ) {
     67                        $wpdb->query( 'ROLLBACK' );
     68                        throw new EscapedDomainException( esc_textarea( __( 'The creator-program allocation is not valid.', 'leanmerce-influencer-collaboration' ) ) );
     69                }
     70                $this->assert_capacity( $opportunity, $offer_id );
     71                if ( ! empty( $allocation['opportunity_product_id'] ) ) {
     72                        $this->assert_product_capacity( (int) $allocation['opportunity_product_id'], (int) $allocation['quantity'], $offer_id );
     73                }
     74        }
    5875        /** @return array{product_id:int,variation_id:int,opportunity_product_id:int,quantity:int,choice_timing:string} */
    5976        private function resolve_offer_product( array $app, int $product_id, int $variation_id ): array { $rows=(new ProductService())->for_opportunity((int)$app['opportunity_id']);foreach($rows as $row){if('when_applying'===$row['choice_timing']&&(int)$row['product_id']===(int)$app['selected_product_id']&&$this->valid_row_choice($row,(int)$app['selected_product_id'],(int)$app['selected_variation_id'])){return$this->choice($row,(int)$app['selected_product_id'],(int)$app['selected_variation_id']);}}foreach($rows as $row){if('merchant_decides'===$row['choice_timing']&&$this->valid_row_choice($row,$product_id,$variation_id)){return$this->choice($row,$product_id,$variation_id);}}foreach($rows as $row){if('after_selection'===$row['choice_timing']){return array('product_id'=>0,'variation_id'=>0,'opportunity_product_id'=>0,'quantity'=>(int)$row['allocation_quantity'],'choice_timing'=>'after_selection');}}if(!$rows){return array('product_id'=>0,'variation_id'=>0,'opportunity_product_id'=>0,'quantity'=>1,'choice_timing'=>'none');}throw new DomainException('A valid product decision is required for the selection offer.'); }
  • leanmerce-influencer-collaboration/tags/1.0.8/src/Storage/EncryptedLocalStorage.php

    r3722211 r3763663  
    88
    99final class EncryptedLocalStorage implements PrivateFileStorageInterface {
     10        /** Budget for the plaintext, ciphertext and unchanged LME1 payload plus request headroom. */
     11        public function max_storable_bytes(): int {
     12                $limit = trim( (string) ini_get( 'memory_limit' ) );
     13                if ( '' === $limit || '-1' === $limit ) {
     14                        return PHP_INT_MAX;
     15                }
     16                $bytes = (int) $limit;
     17                switch ( strtolower( substr( $limit, -1 ) ) ) {
     18                        case 'g': $bytes *= 1073741824; break;
     19                        case 'm': $bytes *= 1048576; break;
     20                        case 'k': $bytes *= 1024; break;
     21                }
     22                return max( 0, (int) floor( ( $bytes - memory_get_usage( true ) - 16777216 ) / 3 ) );
     23        }
     24
    1025        private function directory(): string {
    1126                $uploads = wp_upload_dir();
    … …  
    3550        /** @return array{storage_key:string,size:int,sha256:string} */
    3651        public function store( string $temporary_path ): array {
     52                $size = filesize( $temporary_path );
     53                if ( false !== $size && $size > $this->max_storable_bytes() ) {
     54                        throw new RuntimeException( 'The uploaded file exceeds the safe memory budget for this server.' );
     55                }
    3756                $plain = file_get_contents( $temporary_path );
    3857                if ( false === $plain ) {
  • leanmerce-influencer-collaboration/tags/1.0.8/uninstall.php

    r3722211 r3763663  
    9898
    9999delete_option( 'leanmerce_influencer_collaboration_db_version' );
     100delete_option( 'leanmerce_influencer_collaboration_schema_health' );
     101delete_option( 'leanmerce_influencer_collaboration_schema_retry_after' );
    100102delete_option( 'leanmerce_influencer_collaboration_capabilities_version' );
    101103delete_option( 'leanmerce_influencer_collaboration_delete_data_on_uninstall' );
  • leanmerce-influencer-collaboration/trunk/leanmerce-influencer-collaboration.php

    r3722211 r3763663  
    33 * Plugin Name: Leanmerce Influencer Collaboration
    44 * Description: Manage influencer collaborations directly in WooCommerce, from onboarding and opportunities through fulfillment, content review, and history.
    5  * Version: 1.0.7
     5 * Version: 1.0.8
    66 * Requires at least: 6.9
    77 * Requires PHP: 8.1
    … …  
    2222}
    2323
    24 define( 'LEANMERCE_INFLUENCER_VERSION', '1.0.7' );
     24define( 'LEANMERCE_INFLUENCER_VERSION', '1.0.8' );
    2525define( 'LEANMERCE_INFLUENCER_DB_VERSION', '10.0.0' );
    2626define( 'LEANMERCE_INFLUENCER_FILE', __FILE__ );
  • leanmerce-influencer-collaboration/trunk/readme.txt

    r3722211 r3763663  
    55Tested up to: 7.1
    66Requires PHP: 8.1
    7 Stable tag: 1.0.7
     7Stable tag: 1.0.8
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    4343Free/Core stores influencer profiles, manually entered social references, analytics evidence and historical metric records, private evidence and content files, applications, immutable commercial terms, factual external-agreement confirmations, fulfillment data, content review history, CRM tags, agreed-rights facts, and audit events on the WordPress site. It does not store social-provider credentials or access tokens. Private evidence and content use local encrypted storage. Linked WooCommerce orders follow WooCommerce and site retention settings.
    4444
     45== Upgrade Notice ==
     46
     47= 1.0.8 =
     48Back up the database, private uploads and WordPress salts. Records and completed agreements stay intact. Failed schema checks pause affected features; retry after five minutes once permissions recover. Large encrypted uploads may need smaller files when memory is limited.
     49
    4550== Changelog ==
     51
     52= 1.0.8 =
     53* Keep Free/Core reporting handlers and suspended-profile restrictions independent of Pro version and plugin load order.
     54* Keep localized validation messages readable in REST JSON and escape them once for HTML display.
     55* Verify required database tables, columns, and indexes before accepting setup as complete; retry failed setup with a bounded delay and pause affected plugin features safely.
     56* Count active and consumed allocations consistently when selecting creators, accepting offers, and confirming new external agreements.
     57* Reject encrypted content uploads that exceed the current server memory budget before loading file buffers, with a clear client error; preserve the existing encryption format and keys.
     58* Preserve existing records, completed agreements, upload encryption, and all current minimum requirements.
    4659
    4760= 1.0.7 =
  • leanmerce-influencer-collaboration/trunk/src/Admin/AgreementsPage.php

    r3722211 r3763663  
    55// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned custom tables require direct transactional queries; prepared-SQL security checks remain enabled.
    66use DomainException;
     7use Leanmerce\Influencer\Support\EscapedDomainException;
    78use Leanmerce\Influencer\Agreement\ExternalAgreementService;
    89use Leanmerce\Influencer\Database\Schema;
    … …  
    2223        /** @param array<string,mixed> $row */
    2324        private function state_action(array$row):void{$status=(string)$row['status'];$id=(int)$row['id'];if('contracted'===$status){echo'<a class="button button-primary" href="'.esc_url(admin_url('admin.php?page=leanmerce-fulfillment&collaboration_id='.$id)).'">'.esc_html__('Continue to fulfillment','leanmerce-influencer-collaboration').'</a>';}elseif('awaiting_influencer_acceptance'===$status){echo'<p class="lmi-callout">'.esc_html__('Waiting for creator acceptance. No shop action is available yet.','leanmerce-influencer-collaboration').'</p>';}elseif('content_pending'===$status){echo'<a class="button button-primary" href="'.esc_url(admin_url('admin.php?page=leanmerce-content')).'">'.esc_html__('Review content','leanmerce-influencer-collaboration').'</a>';}elseif('content_complete'===$status){echo'<a class="button" href="'.esc_url(admin_url('admin.php?page=leanmerce-collaborations&collaboration_id='.$id)).'">'.esc_html__('View completed collaboration','leanmerce-influencer-collaboration').'</a>';}}
    24         public function handle():void{check_admin_referer(self::ACTION);if(!isset($_POST['confirmed'])||'1'!==sanitize_text_field((string)wp_unslash($_POST['confirmed']))){wp_die(esc_html__('Explicit confirmation is required.','leanmerce-influencer-collaboration'));}try{(new ExternalAgreementService())->confirm(absint(wp_unslash($_POST['collaboration_id']??0)),get_current_user_id(),sanitize_text_field(wp_unslash($_POST['reference']??'')));}catch(DomainException$e){wp_die(esc_html($e->getMessage()));}wp_safe_redirect(admin_url('admin.php?page=leanmerce-agreements'));exit;}
     25        public function handle():void{check_admin_referer(self::ACTION);if(!isset($_POST['confirmed'])||'1'!==sanitize_text_field((string)wp_unslash($_POST['confirmed']))){wp_die(esc_html__('Explicit confirmation is required.','leanmerce-influencer-collaboration'));}try{(new ExternalAgreementService())->confirm(absint(wp_unslash($_POST['collaboration_id']??0)),get_current_user_id(),sanitize_text_field(wp_unslash($_POST['reference']??'')));}catch(DomainException$e){wp_die(esc_html($e instanceof EscapedDomainException?$e->plain_text():$e->getMessage()));}wp_safe_redirect(admin_url('admin.php?page=leanmerce-agreements'));exit;}
    2526}
  • leanmerce-influencer-collaboration/trunk/src/Agreement/ExternalAgreementService.php

    r3722211 r3763663  
    2727                $json=(string)wp_json_encode($this->sort($snapshot),JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);$hash=hash('sha256',$json);$now=current_time('mysql',true);
    2828                $wpdb->query('START TRANSACTION');
     29                $wpdb->get_var($wpdb->prepare('SELECT id FROM %i WHERE id=%d FOR UPDATE', $t['opportunities'], (int)$c['opportunity_id']));
     30                (new \Leanmerce\Influencer\Selection\SelectionService())->assert_agreement_capacity((int)$c['opportunity_id'], (int)$c['selection_offer_id']);
    2931                if(false===$wpdb->insert($t['final_terms'],array('collaboration_id'=>$collaboration_id,'schema_version'=>'1.0.0','snapshot_json'=>$json,'snapshot_hash'=>$hash,'source_selection_offer_id'=>(int)$offer['id'],'source_commercial_snapshot_hash'=>(string)$offer['commercial_snapshot_hash'],'merchant_legal_snapshot_id'=>0,'influencer_legal_snapshot_id'=>0,'contract_pack_key'=>'external_manual','contract_pack_version'=>'1.0.0','template_id'=>0,'created_gmt'=>$now))){$wpdb->query('ROLLBACK');throw new DomainException('Immutable Final Terms could not be created.');}
    3032                $id=(int)$wpdb->insert_id;$changed=$wpdb->update($t['collaborations'],array('status'=>'contracted','updated_gmt'=>$now),array('id'=>$collaboration_id,'status'=>$c['status']));if(1!==$changed){$wpdb->query('ROLLBACK');throw new DomainException('Collaboration changed before agreement confirmation.');}$wpdb->query('COMMIT');
  • leanmerce-influencer-collaboration/trunk/src/Bootstrap.php

    r3722211 r3763663  
    3737use Leanmerce\Influencer\Rest\FulfillmentController;
    3838use Leanmerce\Influencer\Rest\ContentController;
    39 use Leanmerce\Influencer\Rest\ReportingController;
     39use Leanmerce\Influencer\Core\Rest\ReportingController;
    4040use Leanmerce\Influencer\Roles\Capabilities;
    4141
    … …  
    6767                }
    6868
    69                 ( new MigrationManager() )->maybe_migrate();
     69                if ( ! ( new MigrationManager() )->maybe_migrate() ) {
     70                        add_action( 'admin_notices', static function (): void {
     71                                if ( current_user_can( 'manage_options' ) ) {
     72                                        echo '<div class="notice notice-error"><p>' . esc_html__( 'Influencer Collaboration is temporarily paused because its database setup could not be verified. Check database permissions and reload after five minutes. Existing records and other WooCommerce features are preserved.', 'leanmerce-influencer-collaboration' ) . '</p></div>';
     73                                }
     74                        } );
     75                        return;
     76                }
    7077                Capabilities::maybe_sync();
    7178
  • leanmerce-influencer-collaboration/trunk/src/Content/ContentFileService.php

    r3722211 r3763663  
    88
    99use DomainException;
     10use Leanmerce\Influencer\Support\EscapedDomainException;
    1011use Leanmerce\Influencer\Database\Schema;
    1112use Leanmerce\Influencer\Storage\EncryptedLocalStorage;
    … …  
    1718        public function max_bytes():int{return(int)apply_filters('leanmerce_influencer_content_max_upload_bytes',min(104857600,wp_max_upload_size()));}
    1819        /** @param array{name:string,tmp_name:string,size:int,error:int} $file */
    19         public function upload(int$profile_id,int$user_id,array$file):int{if(UPLOAD_ERR_OK!==(int)$file['error']||(int)$file['size']<1||(int)$file['size']>$this->max_bytes()){throw new DomainException('The content file is empty, too large, or could not be uploaded.');}$allowed=array('jpg|jpeg|jpe'=>'image/jpeg','png'=>'image/png','webp'=>'image/webp','mp4'=>'video/mp4','mov|qt'=>'video/quicktime');$checked=wp_check_filetype_and_ext($file['tmp_name'],$file['name'],$allowed);$mime=(string)($checked['type']??'');$ext=strtolower((string)($checked['ext']??''));$map=array('image/jpeg'=>array('jpg','jpeg','jpe'),'image/png'=>array('png'),'image/webp'=>array('webp'),'video/mp4'=>array('mp4'),'video/quicktime'=>array('mov','qt'));if(!isset($map[$mime])||!in_array($ext,$map[$mime],true)){throw new DomainException('Only inspected JPEG, PNG, WEBP, MP4, or MOV content is accepted.');}$stored=$this->storage->store($file['tmp_name']);global$wpdb;$table=Schema::table_names()['files'];if(false===$wpdb->insert($table,array('influencer_profile_id'=>$profile_id,'storage_key'=>$stored['storage_key'],'original_name'=>sanitize_file_name(wp_basename($file['name'])),'mime_type'=>$mime,'size_bytes'=>$stored['size'],'content_sha256'=>$stored['sha256'],'created_by'=>$user_id,'created_gmt'=>current_time('mysql',true)))){$this->storage->delete($stored['storage_key']);throw new DomainException('Private content metadata could not be saved.');}return(int)$wpdb->insert_id;}
     20        public function upload(int$profile_id,int$user_id,array$file):int{if(UPLOAD_ERR_OK!==(int)$file['error']||(int)$file['size']<1||(int)$file['size']>$this->max_bytes()){throw new DomainException('The content file is empty, too large, or could not be uploaded.');}if($this->storage instanceof EncryptedLocalStorage&&(int)$file['size']>$this->storage->max_storable_bytes()){throw new EscapedDomainException(esc_textarea(__('The content file exceeds the safe memory budget for this server. Choose a smaller file.','leanmerce-influencer-collaboration')));}$allowed=array('jpg|jpeg|jpe'=>'image/jpeg','png'=>'image/png','webp'=>'image/webp','mp4'=>'video/mp4','mov|qt'=>'video/quicktime');$checked=wp_check_filetype_and_ext($file['tmp_name'],$file['name'],$allowed);$mime=(string)($checked['type']??'');$ext=strtolower((string)($checked['ext']??''));$map=array('image/jpeg'=>array('jpg','jpeg','jpe'),'image/png'=>array('png'),'image/webp'=>array('webp'),'video/mp4'=>array('mp4'),'video/quicktime'=>array('mov','qt'));if(!isset($map[$mime])||!in_array($ext,$map[$mime],true)){throw new DomainException('Only inspected JPEG, PNG, WEBP, MP4, or MOV content is accepted.');}$stored=$this->storage->store($file['tmp_name']);global$wpdb;$table=Schema::table_names()['files'];if(false===$wpdb->insert($table,array('influencer_profile_id'=>$profile_id,'storage_key'=>$stored['storage_key'],'original_name'=>sanitize_file_name(wp_basename($file['name'])),'mime_type'=>$mime,'size_bytes'=>$stored['size'],'content_sha256'=>$stored['sha256'],'created_by'=>$user_id,'created_gmt'=>current_time('mysql',true)))){$this->storage->delete($stored['storage_key']);throw new DomainException('Private content metadata could not be saved.');}return(int)$wpdb->insert_id;}
    2021        /** @return array{metadata:array<string,mixed>,content:string} */
    2122        public function read_authorized(int$file_id,int$user_id):array{global$wpdb;$t=Schema::table_names();$row=$wpdb->get_row($wpdb->prepare("SELECT f.*,p.user_id owner_user_id FROM %i f INNER JOIN %i p ON p.id=f.influencer_profile_id INNER JOIN %i sf ON sf.private_file_id=f.id WHERE f.id=%d AND f.deleted_gmt IS NULL LIMIT 1", $t['files'], $t['profiles'], $t['submission_files'],$file_id),ARRAY_A);if(!is_array($row)||((int)$row['owner_user_id']!==$user_id&&!user_can($user_id,'view_leanmerce_influencer_content'))){throw new DomainException('Private content not found.');}return array('metadata'=>$row,'content'=>$this->storage->read((string)$row['storage_key']));}
  • leanmerce-influencer-collaboration/trunk/src/Database/MigrationManager.php

    r3722211 r3763663  
    55namespace Leanmerce\Influencer\Database;
    66
     7// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Current plugin schema, migration locks, and allocation checks require live prepared queries.
     8
    79final class MigrationManager {
    810        private const OPTION_KEY = 'leanmerce_influencer_collaboration_db_version';
     11        private const HEALTH_KEY = 'leanmerce_influencer_collaboration_schema_health';
     12        private const RETRY_KEY = 'leanmerce_influencer_collaboration_schema_retry_after';
     13        private const RETRY_SECONDS = 300;
     14        private const HEALTH_SECONDS = 86400;
    915
    10         public function maybe_migrate(): void {
     16        public function maybe_migrate(): bool {
    1117                $current = (string) get_option( self::OPTION_KEY, '' );
    12                 if ( version_compare( $current, LEANMERCE_INFLUENCER_DB_VERSION, '<' ) ) {
    13                         $this->migrate();
     18                if ( version_compare( $current, LEANMERCE_INFLUENCER_DB_VERSION, '>' ) ) {
     19                        return false;
     20                }
     21                $health = get_option( self::HEALTH_KEY, array() );
     22                if ( LEANMERCE_INFLUENCER_DB_VERSION === $current && is_array( $health ) && ( $health['version'] ?? '' ) === LEANMERCE_INFLUENCER_DB_VERSION && (int) ( $health['until'] ?? 0 ) > time() ) {
     23                        return true;
     24                }
     25                if ( (int) get_option( self::RETRY_KEY, 0 ) > time() ) {
     26                        return false;
     27                }
     28                return $this->migrate();
     29        }
     30
     31        public function migrate(): bool {
     32                global $wpdb;
     33                if ( version_compare( self::current_version(), LEANMERCE_INFLUENCER_DB_VERSION, '>' ) ) {
     34                        return false;
     35                }
     36                $lock = 'lmi-schema-' . substr( hash( 'sha256', DB_NAME . '|' . $wpdb->prefix ), 0, 40 );
     37                if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) ) {
     38                        return false;
     39                }
     40                try {
     41                        delete_option( self::HEALTH_KEY );
     42                        $healthy = SchemaHealth::is_current();
     43                        if ( ! $healthy ) {
     44                                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     45                                foreach ( Schema::create_statements() as $statement ) {
     46                                        dbDelta( $statement );
     47                                }
     48                        }
     49                        if ( ! $healthy && ! SchemaHealth::is_current() ) {
     50                                return $this->failed();
     51                        }
     52                        update_option( self::OPTION_KEY, LEANMERCE_INFLUENCER_DB_VERSION, false );
     53                        if ( self::current_version() !== LEANMERCE_INFLUENCER_DB_VERSION ) {
     54                                return $this->failed();
     55                        }
     56                        $health = array( 'version' => LEANMERCE_INFLUENCER_DB_VERSION, 'until' => time() + self::HEALTH_SECONDS );
     57                        update_option( self::HEALTH_KEY, $health, false );
     58                        if ( get_option( self::HEALTH_KEY, array() ) !== $health ) {
     59                                return $this->failed();
     60                        }
     61                        delete_option( self::RETRY_KEY );
     62                        do_action( 'leanmerce_influencer_core_migrated', LEANMERCE_INFLUENCER_DB_VERSION );
     63                        return true;
     64                } catch ( \Throwable $exception ) {
     65                        return $this->failed();
     66                } finally {
     67                        $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
    1468                }
    1569        }
    1670
    17         public function migrate(): void {
    18                 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    19 
    20                 foreach ( Schema::create_statements() as $statement ) {
    21                         dbDelta( $statement );
    22                 }
    23 
    24                 update_option( self::OPTION_KEY, LEANMERCE_INFLUENCER_DB_VERSION, false );
    25                 do_action( 'leanmerce_influencer_core_migrated', LEANMERCE_INFLUENCER_DB_VERSION );
     71        private function failed(): bool {
     72                delete_option( self::HEALTH_KEY );
     73                update_option( self::RETRY_KEY, time() + self::RETRY_SECONDS, false );
     74                return false;
    2675        }
    2776
  • leanmerce-influencer-collaboration/trunk/src/Infrastructure/Activator.php

    r3722211 r3763663  
    1717                }
    1818
    19                 ( new MigrationManager() )->migrate();
     19                if ( ! ( new MigrationManager() )->maybe_migrate() ) {
     20                        return;
     21                }
    2022                Capabilities::install();
    2123
  • leanmerce-influencer-collaboration/trunk/src/Rest/AgreementController.php

    r3722211 r3763663  
    66
    77use DomainException;
     8use Leanmerce\Influencer\Support\EscapedDomainException;
    89use Leanmerce\Influencer\Agreement\ExternalAgreementService;
    910use WP_Error;
    … …  
    1415        public function register():void{add_action('rest_api_init',array($this,'routes'));}
    1516        public function routes():void{register_rest_route('leanmerce-influencer-collaboration/v1','/agreements/(?P<id>\d+)/external',array('methods'=>'POST','callback'=>array($this,'confirm'),'permission_callback'=>static fn():bool=>current_user_can('manage_leanmerce_influencer_contracts')));}
    16         public function confirm(WP_REST_Request$r):WP_REST_Response|WP_Error{try{return new WP_REST_Response((new ExternalAgreementService())->confirm((int)$r['id'],get_current_user_id(),sanitize_text_field((string)$r->get_param('reference'))),201);}catch(DomainException$e){return new WP_Error('invalid_external_agreement',$e->getMessage(),array('status'=>400));}}
     17        public function confirm(WP_REST_Request$r):WP_REST_Response|WP_Error{try{return new WP_REST_Response((new ExternalAgreementService())->confirm((int)$r['id'],get_current_user_id(),sanitize_text_field((string)$r->get_param('reference'))),201);}catch(DomainException$e){return new WP_Error('invalid_external_agreement',$e instanceof EscapedDomainException?$e->plain_text():$e->getMessage(),array('status'=>400));}}
    1718}
  • leanmerce-influencer-collaboration/trunk/src/Rest/ContentController.php

    r3722211 r3763663  
    66
    77use DomainException;
     8use Leanmerce\Influencer\Support\EscapedDomainException;
    89use Leanmerce\Influencer\Content\ContentFileService;
    910use Leanmerce\Influencer\Content\ContentRepository;
    … …  
    4445        public function start(WP_REST_Request$r):WP_REST_Response|WP_Error{return$this->run(fn()=>(new ContentWorkflowService())->start((int)$r['id'],get_current_user_id(),true),201);}
    4546        public function review(WP_REST_Request$r):WP_REST_Response|WP_Error{$input=InputSanitizer::json($r,array('action','feedback','version'));return$this->run(fn()=>(new ContentWorkflowService())->review((int)$r['id'],get_current_user_id(),sanitize_key((string)($input['action']??'')),(string)($input['feedback']??''),absint($input['version']??0)));}
    46         private function run(callable$fn,int$status=200):WP_REST_Response|WP_Error{try{return new WP_REST_Response($fn(),$status);}catch(DomainException$e){$conflict=(bool)preg_match('/changed|already|newer|state|currently|limit/i',$e->getMessage());return new WP_Error('invalid_content_operation',$e->getMessage(),array('status'=>$conflict?409:400));}}
     47        private function run(callable$fn,int$status=200):WP_REST_Response|WP_Error{try{return new WP_REST_Response($fn(),$status);}catch(DomainException$e){$message=$e instanceof EscapedDomainException?$e->plain_text():$e->getMessage();$conflict=(bool)preg_match('/changed|already|newer|state|currently|limit/i',$message);return new WP_Error('invalid_content_operation',$message,array('status'=>$conflict?409:400));}}
    4748}
  • leanmerce-influencer-collaboration/trunk/src/Selection/SelectionService.php

    r3722211 r3763663  
    88
    99use DomainException;
     10use Leanmerce\Influencer\Support\EscapedDomainException;
    1011use Leanmerce\Influencer\Application\ApplicationRepository;
    1112use Leanmerce\Influencer\Application\ApplicationService;
    … …  
    5455        private function respond( array $offer, string $status, int $user_id ): void { global $wpdb;$t=Schema::table_names();$wpdb->query('START TRANSACTION');$changed=$wpdb->query($wpdb->prepare("UPDATE %i SET status=%s,pending_guard=NULL,responded_gmt=%s WHERE id=%d AND status='pending' AND pending_guard=1", $t['selection_offers'],$status,current_time('mysql',true),(int)$offer['id']));if(1!==$changed){$wpdb->query('ROLLBACK');throw new DomainException('This selection offer changed before your response.');}$wpdb->update($t['soft_allocations'],array('status'=>'released','released_gmt'=>current_time('mysql',true)),array('selection_offer_id'=>(int)$offer['id'],'status'=>'active'));$wpdb->update($t['applications'],array('status'=>'submitted','updated_gmt'=>current_time('mysql',true)),array('id'=>(int)$offer['application_id']));$wpdb->query('COMMIT');(new AuditService())->record('application',(int)$offer['application_id'],'selection_offer.'.$status,array('context'=>(string)$offer['commercial_snapshot_hash']),$user_id);(new AuditService())->record('application',(int)$offer['application_id'],'soft_allocation.released',array('source'=>$status),$user_id); }
    5556        private function owned_offer( int $id, int $user_id ): array { $offer=$this->offer($id);if(!$offer){throw new DomainException('Selection offer not found.');}(new ApplicationService())->owned_application((int)$offer['application_id'],$user_id);return$offer; }
    56         private function assert_capacity( array $opportunity, ?int $exclude_offer ): void { if(empty($opportunity['max_collaborations'])){return;}global $wpdb;$t=Schema::table_names();if($exclude_offer){$count=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE opportunity_id=%d AND status='active' AND selection_offer_id<>%d",$t['soft_allocations'],(int)$opportunity['id'],$exclude_offer));}else{$count=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE opportunity_id=%d AND status='active'",$t['soft_allocations'],(int)$opportunity['id']));}if($count>=(int)$opportunity['max_collaborations']){throw new DomainException('This opportunity has reached its collaboration capacity.');} }
    57         private function assert_product_capacity( int $opportunity_product_id, int $quantity, ?int $exclude_offer ): void { global $wpdb;$t=Schema::table_names();$cap=$wpdb->get_var($wpdb->prepare("SELECT capacity FROM %i WHERE id=%d",$t['opportunity_products'],$opportunity_product_id));if(null===$cap){return;}if($exclude_offer){$used=(int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(quantity),0) FROM %i WHERE opportunity_product_id=%d AND status='active' AND selection_offer_id<>%d",$t['soft_allocations'],$opportunity_product_id,$exclude_offer));}else{$used=(int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(quantity),0) FROM %i WHERE opportunity_product_id=%d AND status='active'",$t['soft_allocations'],$opportunity_product_id));}if($used+$quantity>(int)$cap){throw new DomainException('This product allocation has reached capacity.');} }
     57        private function assert_capacity( array $opportunity, ?int $exclude_offer ): void { if(empty($opportunity['max_collaborations'])){return;}global $wpdb;$t=Schema::table_names();if($exclude_offer){$count=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE opportunity_id=%d AND status IN ('active','consumed') AND selection_offer_id<>%d",$t['soft_allocations'],(int)$opportunity['id'],$exclude_offer));}else{$count=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE opportunity_id=%d AND status IN ('active','consumed')",$t['soft_allocations'],(int)$opportunity['id']));}if($count>=(int)$opportunity['max_collaborations']){$wpdb->query('ROLLBACK');throw new DomainException('This opportunity has reached its collaboration capacity.');} }
     58        private function assert_product_capacity( int $opportunity_product_id, int $quantity, ?int $exclude_offer ): void { global $wpdb;$t=Schema::table_names();$cap=$wpdb->get_var($wpdb->prepare("SELECT capacity FROM %i WHERE id=%d",$t['opportunity_products'],$opportunity_product_id));if(null===$cap){return;}if($exclude_offer){$used=(int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(quantity),0) FROM %i WHERE opportunity_product_id=%d AND status IN ('active','consumed') AND selection_offer_id<>%d",$t['soft_allocations'],$opportunity_product_id,$exclude_offer));}else{$used=(int)$wpdb->get_var($wpdb->prepare("SELECT COALESCE(SUM(quantity),0) FROM %i WHERE opportunity_product_id=%d AND status IN ('active','consumed')",$t['soft_allocations'],$opportunity_product_id));}if($used+$quantity>(int)$cap){$wpdb->query('ROLLBACK');throw new DomainException('This product allocation has reached capacity.');} }
     59
     60        /** Validate a still-pending agreement against the same capacity policy, without rewriting prior terms. */
     61        public function assert_agreement_capacity( int $opportunity_id, int $offer_id ): void {
     62                global $wpdb;
     63                $t = Schema::table_names();
     64                $opportunity = ( new OpportunityRepository() )->find( $opportunity_id );
     65                $allocation = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE selection_offer_id=%d', $t['soft_allocations'], $offer_id ), ARRAY_A );
     66                if ( ! $opportunity || ! $allocation || 'active' !== $allocation['status'] ) {
     67                        $wpdb->query( 'ROLLBACK' );
     68                        throw new EscapedDomainException( esc_textarea( __( 'The creator-program allocation is not valid.', 'leanmerce-influencer-collaboration' ) ) );
     69                }
     70                $this->assert_capacity( $opportunity, $offer_id );
     71                if ( ! empty( $allocation['opportunity_product_id'] ) ) {
     72                        $this->assert_product_capacity( (int) $allocation['opportunity_product_id'], (int) $allocation['quantity'], $offer_id );
     73                }
     74        }
    5875        /** @return array{product_id:int,variation_id:int,opportunity_product_id:int,quantity:int,choice_timing:string} */
    5976        private function resolve_offer_product( array $app, int $product_id, int $variation_id ): array { $rows=(new ProductService())->for_opportunity((int)$app['opportunity_id']);foreach($rows as $row){if('when_applying'===$row['choice_timing']&&(int)$row['product_id']===(int)$app['selected_product_id']&&$this->valid_row_choice($row,(int)$app['selected_product_id'],(int)$app['selected_variation_id'])){return$this->choice($row,(int)$app['selected_product_id'],(int)$app['selected_variation_id']);}}foreach($rows as $row){if('merchant_decides'===$row['choice_timing']&&$this->valid_row_choice($row,$product_id,$variation_id)){return$this->choice($row,$product_id,$variation_id);}}foreach($rows as $row){if('after_selection'===$row['choice_timing']){return array('product_id'=>0,'variation_id'=>0,'opportunity_product_id'=>0,'quantity'=>(int)$row['allocation_quantity'],'choice_timing'=>'after_selection');}}if(!$rows){return array('product_id'=>0,'variation_id'=>0,'opportunity_product_id'=>0,'quantity'=>1,'choice_timing'=>'none');}throw new DomainException('A valid product decision is required for the selection offer.'); }
  • leanmerce-influencer-collaboration/trunk/src/Storage/EncryptedLocalStorage.php

    r3722211 r3763663  
    88
    99final class EncryptedLocalStorage implements PrivateFileStorageInterface {
     10        /** Budget for the plaintext, ciphertext and unchanged LME1 payload plus request headroom. */
     11        public function max_storable_bytes(): int {
     12                $limit = trim( (string) ini_get( 'memory_limit' ) );
     13                if ( '' === $limit || '-1' === $limit ) {
     14                        return PHP_INT_MAX;
     15                }
     16                $bytes = (int) $limit;
     17                switch ( strtolower( substr( $limit, -1 ) ) ) {
     18                        case 'g': $bytes *= 1073741824; break;
     19                        case 'm': $bytes *= 1048576; break;
     20                        case 'k': $bytes *= 1024; break;
     21                }
     22                return max( 0, (int) floor( ( $bytes - memory_get_usage( true ) - 16777216 ) / 3 ) );
     23        }
     24
    1025        private function directory(): string {
    1126                $uploads = wp_upload_dir();
    … …  
    3550        /** @return array{storage_key:string,size:int,sha256:string} */
    3651        public function store( string $temporary_path ): array {
     52                $size = filesize( $temporary_path );
     53                if ( false !== $size && $size > $this->max_storable_bytes() ) {
     54                        throw new RuntimeException( 'The uploaded file exceeds the safe memory budget for this server.' );
     55                }
    3756                $plain = file_get_contents( $temporary_path );
    3857                if ( false === $plain ) {
  • leanmerce-influencer-collaboration/trunk/uninstall.php

    r3722211 r3763663  
    9898
    9999delete_option( 'leanmerce_influencer_collaboration_db_version' );
     100delete_option( 'leanmerce_influencer_collaboration_schema_health' );
     101delete_option( 'leanmerce_influencer_collaboration_schema_retry_after' );
    100102delete_option( 'leanmerce_influencer_collaboration_capabilities_version' );
    101103delete_option( 'leanmerce_influencer_collaboration_delete_data_on_uninstall' );
Note: See TracChangeset for help on using the changeset viewer.